/* ---------------------------------------------------------------------
 * Bind SyncChange
 * ---------------------------------------------------------------------
 * Copyright (C) 2007-2020 The NOC Project
 * See LICENSE for details
 * ---------------------------------------------------------------------
 */
use crate::dnszone::DNSZone;
use crate::error::Error;
use core::cmp::{max, min};
use datastream::change::{Change, ChangeService};
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;

// Tabulation step in zone file
const ZONE_TAB_STOP: usize = 8;
// Max unwrapped size of TXT data
const MAX_TXT: usize = 128;
// Pretty time constants
static ZERO_TIME: &str = "zero";
const PRETTY_TIME_NAME: [&'static str; 5] = ["week", "day", "hour", "min", "sec"];
const PRETTY_TIME_VALUE: [usize; 5] = [604800, 86400, 3600, 60, 1];
//
const INVENTORY_VERSION: &str = "1.0";
const INVENTORY_PATH: &str = ".zones.json";
const INCLUDE_PATH: &str = "zones.conf";

#[derive(Debug)]
pub struct BindChangeService {
    zones_path: String,
    zones_chroot_path: Option<String>,
    dry_run: bool,
    inventory_path: String,
    inventory: ZoneInventory,
    rndc_reload_zones: Vec<String>,
    rndc_reload: bool,
}

impl BindChangeService {
    pub fn new() -> BindChangeServiceBuilder {
        info!("Initializing BIND change service");
        BindChangeServiceBuilder {
            zones_path: None,
            zones_chroot_path: None,
            dry_run: None,
        }
    }

    // Write BIND-style zone
    pub fn write_bind_zone(writer: &mut dyn std::io::Write, zone: &DNSZone) -> Result<(), Error> {
        if zone.records.len() == 0 {
            Err(Error::ParseError(String::from("Missed SOA record")))?
        }
        // Parse SOA
        let soa = zone.get_soa()?;
        let pretty_name = zone.pretty_name()?;
        // Extract SOA data
        // Calculate columns size
        let mut l_name: usize = 0;
        let mut l_type: usize = 0;
        for rr in &zone.records[1..] {
            l_name = max(l_name, rr.name.len());
            l_type = max(l_type, rr.rr_type.len());
        }
        l_name = (l_name / ZONE_TAB_STOP + 1) * ZONE_TAB_STOP;
        l_type = (l_type / ZONE_TAB_STOP + 1) * ZONE_TAB_STOP;
        let txt_indent = " ".repeat(l_name);
        //
        write!(
            writer,
            r#";; {pretty_name}
;; WARNING: Auto-generated zone file
;; Do not edit manually
;; Generated by NOC (http://getnoc.com)
$ORIGIN {name}.
$TTL {ttl}
@ IN SOA {primary} {contact} (
	{serial:>10} ; Serial
	{refresh:>10} ; Refresh ({pretty_refresh})
	{retry:>10} ; Retry ({pretty_retry})
	{expire:>10} ; Expire ({pretty_expire})
	{ttl:>10} ; Minimum ({pretty_ttl})
)
"#,
            pretty_name = pretty_name,
            name = zone.name,
            ttl = soa.ttl,
            primary = soa.primary,
            contact = soa.contact,
            serial = zone.serial,
            refresh = soa.refresh,
            pretty_refresh = Self::pretty_time(soa.refresh),
            retry = soa.retry,
            pretty_retry = Self::pretty_time(soa.retry),
            expire = soa.expire,
            pretty_expire = Self::pretty_time(soa.expire),
            pretty_ttl = Self::pretty_time(soa.ttl)
        )?;
        // Write records
        let n_suffix = format!(".{}.", zone.name);
        let n_suffix_len = n_suffix.len();

        for rr in &zone.records[1..] {
            let mut content = rr.data.to_string();
            if rr.is_cname() && content.ends_with(&n_suffix) {
                content = content[..content.len() - n_suffix_len].to_string();
            }
            if rr.priority.is_some() {
                content = format!("{} {}", rr.priority.unwrap(), content);
            }
            if rr.is_txt() {
                for (ln, line) in Self::split_txt(&content).iter().enumerate() {
                    if ln == 0 {
                        write!(
                            writer,
                            "{}{}{}\n",
                            Self::pad_to_width(&rr.name, l_name),
                            Self::pad_to_width(&rr.rr_type, l_type),
                            line
                        )?;
                    } else {
                        write!(writer, "{}{}\n", txt_indent, line)?;
                    }
                }
            } else {
                write!(
                    writer,
                    "{}{}{}\n",
                    Self::pad_to_width(&rr.name, l_name),
                    Self::pad_to_width(&rr.rr_type, l_type),
                    &content
                )?;
            }
        }
        write!(
            writer,
            r#";;
;; End of auto-generated zone
;;
"#
        )?;
        Ok(())
    }

    fn write_bind_include(
        &self,
        writer: &mut dyn std::io::Write,
    ) -> Result<(), datastream::error::Error> {
        let i = r#"{{ $inv := .}}// NOC zone inventory file
// WARNING: Auto-generated zone file
// Do not edit manually
// Generated by NOC (http://getnoc.com)
{{ range $i, $zone := .Data.Zones }}
// ID: {{$zone.Id}}
zone "{{$zone.Name}}" IN {
{{- if eq $zone.Type "master"}}
    type master;
    file "{{$inv.GetZonePath $zone}}";
{{ end -}}
};
{{ end }}
//
// End of auto-generated file
//"#;
        write!(
            writer,
            r#"// NOC zone inventory file
// WARNING: Auto-generated zone file
// Do not edit manually
// Generated by NOC (http://getnoc.com)
"#
        )?;
        for zi in &self.inventory.zones {
            let zone_path = if self.zones_chroot_path.is_none() {
                format!("{}.zone", zi.name)
            } else {
                format!(
                    "{}.zone",
                    Path::new(self.zones_chroot_path.as_ref().unwrap())
                        .join(&zi.name)
                        .to_str()
                        .unwrap()
                )
            };
            write!(
                writer,
                r#"// ID: {zone_id}
zone "{zone_name}" IN {{
    type master;
    file "{zone_path}";
}};
"#,
                zone_id = zi.id,
                zone_name = zi.name,
                zone_path = zone_path
            )?
        }
        write!(
            writer,
            r#"//
// End of auto-generated file
//"#
        )?;
        Ok(())
    }

    fn pad_to_width(s: &String, width: usize) -> String {
        let s_len = s.len();
        if s_len >= width {
            s.to_string()
        } else {
            let mut r = String::from(s);
            r.push_str(&" ".repeat(width - s_len));
            r
        }
    }

    fn split_txt(value: &String) -> Vec<String> {
        if value.len() <= MAX_TXT {
            // Single line
            return if value.starts_with("\"") {
                vec![value.to_string()]
            } else {
                vec![format!("\"{}\"", value)]
            };
        }
        // Split to multiple lines
        let mut start = 0;
        let mut lv = value.len();
        if value.starts_with("\"") && value.ends_with("\"") {
            // Unquote
            start += 1;
            lv -= 1;
        }
        let mut r = vec![String::from("(")];
        while start < lv {
            let stop = min(start + MAX_TXT, lv);
            r.push(format!("\"{}\"", value[start..stop].to_string()));
            start = stop + 1;
        }
        r.push(String::from(")"));
        r
    }

    pub fn pretty_time(t: usize) -> String {
        let mut v = t;
        if t == 0 {
            return String::from(ZERO_TIME);
        }
        let mut r = String::new();
        let mut to_add_space = false;
        for i in 0..PRETTY_TIME_VALUE.len() {
            let limit = PRETTY_TIME_VALUE[i];
            if v >= limit {
                if to_add_space {
                    r.push_str(" ");
                }
                let rr = v / limit;
                v = v % limit;
                r.push_str(
                    (if rr > 1 {
                        format!("{} {}s", rr, PRETTY_TIME_NAME[i])
                    } else {
                        format!("{} {}", rr, PRETTY_TIME_NAME[i])
                    })
                    .as_str(),
                );
                to_add_space = true;
            }
        }
        r
    }

    fn get_zone_path(&self, name: &String) -> String {
        Path::new(&self.zones_path)
            .join(format!("{}.zone", &name))
            .to_str()
            .unwrap()
            .to_string()
    }

    fn get_include_path(&self) -> String {
        Path::new(&self.zones_path)
            .join(INCLUDE_PATH)
            .to_str()
            .unwrap()
            .to_string()
    }

    // Schedule rndc reload <zone>
    fn rndc_schedule_zone_reload(&mut self, zone: &String) {
        if !self.rndc_reload {
            self.rndc_reload_zones.push(zone.clone())
        }
    }
    // Schedule rndc reload
    fn rndc_schedule_reload(&mut self) {
        self.rndc_reload = true
    }
    // Issue proper rndc reload commands
    fn rndc_apply(&mut self) {
        if self.rndc_reload {
            info!("rndc reload");
            Command::new("rndc")
                .arg("reload")
                .output()
                .map(|o| info!("rndc status: {}", o.status.success()))
                .map_err(|e| {
                    info!("rndc error: {}", e);
                    e
                });
        } else {
            for zone in self.rndc_reload_zones.iter() {
                info!("rndc reload {}", zone);
                Command::new("rndc")
                    .arg("reload")
                    .arg(zone)
                    .output()
                    .map(|o| info!("rndc status: {}", o.status.success()))
                    .map_err(|e| {
                        info!("rndc error: {}", e);
                        e
                    });
            }
        }
        self.rndc_reload = false;
        self.rndc_reload_zones.clear()
    }
}

impl ChangeService<DNSZone> for BindChangeService {
    // Process change
    fn change(&mut self, change: &Change<DNSZone>) -> Result<(), datastream::error::Error> {
        let zone = change.data.as_ref().unwrap();
        let zone_name = zone.name.clone();
        info!("Zone changed: {} ({})", &zone_name, &change.change_id);
        let zone_path = self.get_zone_path(&zone_name);
        let tmp_path = format!("{}~", &zone_path);
        info!("Writing zone to {}", zone_path);
        {
            let mut f = fs::File::create(&tmp_path)?;
            Self::write_bind_zone(&mut f, zone).map_err(|e| {
                error!("Cannot write to {}: {}", tmp_path, e);
                datastream::error::Error::ChangeError(e.to_string())
            })?;
        }
        // Replace
        std::fs::rename(&tmp_path, &zone_path).map_err(|e| {
            error!("Cannot rename {} to {}: {}", &tmp_path, &zone_path, e);
            e
        })?;
        //
        self.inventory.update_zone(change)?;
        self.rndc_schedule_zone_reload(&zone_name);
        Ok(())
    }
    // Process deletion
    fn delete(&mut self, change: &Change<DNSZone>) -> Result<(), datastream::error::Error> {
        info!("Zone deleted: {} ({})", &change.id, &change.change_id);
        self.inventory.get_by_id(&change.id).map(|zi| {
            let zone_path = self.get_zone_path(&zi.name);
            debug!("Removing file: {}", &zone_path);
            std::fs::remove_file(&zone_path);
        });
        self.inventory.delete_zone(change)?;
        self.rndc_schedule_reload();
        Ok(())
    }
    // Ensure all changes are applied
    fn apply(&mut self) -> Result<(), datastream::error::Error> {
        self.inventory.save(&self.inventory_path)?;
        // Rebuild include file
        let include_path = self.get_include_path();
        let tmp_path = format!("{}~", &include_path);
        info!("Writing BIND include file: {}", &include_path);
        {
            let mut f = fs::File::create(&tmp_path)?;
            self.write_bind_include(&mut f).map_err(|e| {
                error!("Cannot write to {}: {}", tmp_path, e);
                datastream::error::Error::IOError(e.to_string())
            })?;
        }
        std::fs::rename(&tmp_path, &include_path).map_err(|e| {
            error!("Cannot rename {} to {}: {}", &tmp_path, &include_path, e);
            e
        })?;
        self.rndc_apply();
        Ok(())
    }
}

//
// Builder for BindChangeService
//
pub struct BindChangeServiceBuilder {
    zones_path: Option<String>,
    zones_chroot_path: Option<String>,
    dry_run: Option<bool>,
}

impl BindChangeServiceBuilder {
    pub fn build(&self) -> Result<BindChangeService, Error> {
        let zones_path = self
            .zones_path
            .clone()
            .ok_or(Error::NotConfiguredError(String::from("zones_path")))?;
        let inventory_path = String::from(
            Path::new(&zones_path)
                .join(INVENTORY_PATH)
                .to_str()
                .unwrap(),
        );
        let svc = BindChangeService {
            zones_path: zones_path,
            zones_chroot_path: self.zones_chroot_path.clone(),
            dry_run: self
                .dry_run
                .ok_or(Error::NotConfiguredError(String::from("dry_run")))?,
            inventory_path: inventory_path.clone(),
            inventory: ZoneInventory::load(&inventory_path)?,
            rndc_reload_zones: Vec::new(),
            rndc_reload: false,
        };
        info!("BIND change service is started");
        Ok(svc)
    }

    pub fn zones_path(&mut self, zones_path: &String) -> &mut Self {
        self.zones_path = Some(zones_path.clone());
        self
    }

    pub fn zones_chroot_path(&mut self, zones_chroot_path: &String) -> &mut Self {
        if zones_chroot_path != "" {
            self.zones_chroot_path = Some(zones_chroot_path.clone());
        }
        self
    }

    pub fn dry_run(&mut self, dry_run: bool) -> &mut Self {
        self.dry_run = Some(dry_run);
        self
    }
}
//
// Zone inventory data
//
#[derive(Debug, Serialize, Deserialize)]
pub struct ZoneInventory {
    version: String,
    zones: Vec<ZoneInventoryItem>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ZoneInventoryItem {
    id: String,
    name: String,
    #[serde(rename(deserialize = "type", serialize = "type"))]
    zone_type: String,
    masters: Vec<String>,
    slaves: Vec<String>,
}

impl ZoneInventory {
    pub fn load(path: &String) -> Result<ZoneInventory, datastream::error::Error> {
        debug!("Loading zone inventory from {}", &path);
        let inv_path = Path::new(&path);
        let mut inv = if inv_path.exists() {
            // Load stored inventory
            let f = std::fs::File::open(&inv_path).map_err(|e| {
                error!("Cannot load zone inventory: {}", e);
                e
            })?;
            serde_json::from_reader(&f).map_err(|e| {
                error!("Cannot parse zone inventory: {}", e);
                datastream::error::Error::ParseError(e.to_string())
            })?
        } else {
            debug!("Zone inventory is not found. Creating new one");
            ZoneInventory {
                version: String::from(INVENTORY_VERSION),
                zones: Vec::new(),
            }
        };
        // Sort zones
        inv.zones.sort_by(|a, b| a.name.cmp(&b.name));
        // Check permissions by save
        inv.save(&path)?;
        Ok(inv)
    }
    pub fn save(&self, path: &String) -> Result<(), datastream::error::Error> {
        debug!("Saving zone inventory to {}", &path);
        // Deserialize inventory
        let data = serde_json::to_string_pretty(self)?;
        // Write temporary file
        let tmp_path = format!("{}~", &path);
        std::fs::write(&tmp_path, &data).map_err(|e| {
            error!("Cannot write {}: {}", &tmp_path, e);
            e
        })?;
        // Replace
        std::fs::rename(&tmp_path, &path).map_err(|e| {
            error!("Cannot rename {} to {}: {}", &tmp_path, &path, e);
            e
        })?;
        Ok(())
    }
    pub fn update_zone(
        &mut self,
        change: &Change<DNSZone>,
    ) -> Result<(), datastream::error::Error> {
        let name = change.data.as_ref().unwrap().name.clone();
        info!("Updating zone: {}", &name);
        match self.zones.binary_search_by(|p| p.name.cmp(&name)) {
            // Found, replace
            Ok(i) => self.zones[i] = ZoneInventoryItem::from(change),
            // Not found, insert sorted
            Err(i) => self.zones.insert(i, ZoneInventoryItem::from(change)),
        }
        Ok(())
    }
    pub fn delete_zone(
        &mut self,
        change: &Change<DNSZone>,
    ) -> Result<(), datastream::error::Error> {
        info!("Delete zone {}", change.id);
        self.zones
            .iter()
            .position(|p| p.id == change.id)
            .map(|i| self.zones.remove(i));
        Ok(())
    }

    pub fn get_by_id(&self, id: &String) -> Option<&ZoneInventoryItem> {
        self.zones
            .iter()
            .position(|p| p.id == *id)
            .map_or(None, |i| Some(&self.zones[i]))
    }
}

impl ZoneInventoryItem {
    pub fn from(change: &Change<DNSZone>) -> ZoneInventoryItem {
        let zone = change.data.as_ref().unwrap();
        ZoneInventoryItem {
            id: change.id.clone(),
            name: zone.name.clone(),
            zone_type: String::from("master"),
            masters: zone.masters.clone(),
            slaves: zone.slaves.clone(),
        }
    }
}
